<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Youden Index Calculator</title>
    <script>
console.log('Loaded script v1.0.0');

function runCalculation() {
    const testName = document.getElementById("testName").value;
    let a = parseFloat(document.getElementById("a").value);
    let b = parseFloat(document.getElementById("b").value);
    let c = parseFloat(document.getElementById("c").value);
    let d = parseFloat(document.getElementById("d").value);
    let se_input = parseFloat(document.getElementById("se_input").value);
    let sp_input = parseFloat(document.getElementById("sp_input").value);
    let dplus_input = parseFloat(document.getElementById("dplus_input").value);
    let n_input = parseFloat(document.getElementById("n_input").value);
    let prev_input = parseFloat(document.getElementById("prev_input").value);

    let se = null;
    let s_se = null;
    let z_se = null;
    let zH0_se = null;
    let z_diff_se = null;
    let p_se = null;
    let ci_se_lb = null;
    let ci_se_ub = null;

    let sp = null;
    let s_sp = null;
    let z_sp = null;
    let zH0_sp = null;
    let z_diff_sp = null;
    let p_sp = null;
    let ci_sp_lb = null;
    let ci_sp_ub = null;

    let J = null;
    let s_J2 = null;
    let z_J2 = null;
    let p_J2 = null;
    let ci_J2_lb = null;
    let ci_J2_ub = null;

    let ppv = null;
    let s_ppv = null;
    let z_ppv = null;
    let zH0_ppv = null;
    let z_diff_ppv = null;
    let p_ppv = null;
    let ci_ppv_lb = null, ci_ppv_ub = null;

    let npvm1 = null;
    let s_npvm1 = null;
    let z_npvm1 = null;
    let zH0_npvm1 = null;
    let z_diff_npvm1 = null;
    let p_npvm1 = null;
    let ci_npvm1_lb = null, ci_npvm1_ub = null;

    if(!isNaN(prev_input))
    {
      prev_input = prev_input/100
    }

    // to create 2x2 table
if (!isNaN(se_input) && !isNaN(sp_input))
{
    se_input = se_input / 100;
    sp_input = sp_input / 100;

    if (isNaN(n_input)) n_input = 1;

    if (isNaN(dplus_input) && isNaN(prev_input))
    {
        showError("Please enter either the number of diseased patients (D⁺) or the prevalence to build the 2x2 table, or provide a complete 2x2 table for computation.");
        return;
    }
    else
    {
        clearError();
    }

    let prev_aux = !isNaN(dplus_input) ? dplus_input / n_input : prev_input;

    a = se_input * (prev_aux * n_input);
    c = (1 - se_input) * (prev_aux * n_input);
    d = sp_input * ((1 - prev_aux) * n_input);
    b = (1 - sp_input) * ((1 - prev_aux) * n_input);
}
// console.log("se_input=",se_input);
// console.log("sp_input=",sp_input);
// console.log("dplus_input=",dplus_input);
// console.log("n_input=",n_input);
// console.log("prev_input=",prev_input);
// console.log("a=",a);
// console.log("b=",b);
// console.log("c=",c);
// console.log("d=",d);

    const n = a + b + c + d;
    if(a+c > 0)
    {
      se = a / (a + c);
      s_se = Math.sqrt((a * c) / Math.pow(a + c, 3));
      z_se = se / s_se;
      zH0_se = 0.5 / s_se;
      z_diff_se = z_se - zH0_se;
      p_se = z_diff_se > 0 ? 1 - normalCDF(z_diff_se) : normalCDF(z_diff_se);
      ci_se_lb = Math.max(0, se - normalQuantile(0.975) * s_se);
      ci_se_ub = Math.min(1, se + normalQuantile(0.975) * s_se);
    }
// console.log("ci_se_lb=",ci_se_lb);
// console.log("ci_se_ub=",ci_se_ub);
// console.log("s_se=",s_se);
// console.log("z=",z_se);
// console.log("H0=",zH0_se);
// console.log("diff=",z_diff_se);
// console.log("p_se=",p_se);
// console.log("ci_se_lb=",ci_se_lb);
// console.log("ci_se_lb=",ci_se_ub);

    if(b+d > 0)
    {
      sp = d / (b + d);
      s_sp = Math.sqrt((b * d) / Math.pow(b + d, 3));
      z_sp = sp / s_sp;
      zH0_sp = 0.5 / s_sp;
      z_diff_sp = z_sp - zH0_sp;
      p_sp = z_diff_sp > 0 ? 1 - normalCDF(z_diff_sp) : normalCDF(z_diff_sp);
      ci_sp_lb = Math.max(0, sp - normalQuantile(0.975) * s_sp);
      ci_sp_ub = Math.min(1, sp + normalQuantile(0.975) * s_sp);
    }
// console.log("ci_sp_lb=",ci_sp_lb);
// console.log("ci_sp_ub=",ci_sp_ub);
// console.log("s_sp=",s_sp);
// console.log("z=",z_sp);
// console.log("H0=",zH0_sp);
// console.log("diff=",z_diff_sp);
// console.log("p_sp=",p_sp);
// console.log("ci_sp_lb=",ci_sp_lb);
// console.log("ci_sp_ub=",ci_sp_ub);

//    prev = (a + c) / n;
//    prev_lb = betaInv(0.05, a + c, n - (a + c) + 1);
//    prev_ub = betaInv(0.95, a + c + 1, n - (a + c));
// console.log("prev=",prev);
// console.log("prev_lb=",prev_lb);
// console.log("prev_ub=",prev_ub);

    J = se + sp - 1;

    if (prev_input > 0 && prev_input < 1)
    {
      // build fake table
      let a_f = se*(prev_input*n)
      let c_f = (1-se)*(prev_input*n)
      let d_f = sp*((1-prev_input)*n)
      let b_f = (1-sp)*((1-prev_input)*n)
// console.log("a_f=",a_f);
// console.log("b_f=",b_f);
// console.log("c_f=",c_f);
// console.log("d_f=",d_f);

      // CI95 prevalence, assuming binomial
      //let x = Math.round(a_f+c_f);
      //ci_prev_lb = betaInv(0.025, x,  n - x + 1);
      //ci_prev_ub = betaInv(0.975, x+1, n-x);
      //ci_prev_lb = jStat.beta.inv(0.025, x,  n - x + 1);
      //ci_prev_ub = jStat.beta.inv(0.975, x+1, n-x);

      let x = (a_f + c_f)/n;   // proporção estimada de doentes
      let ci = wilsonCI(x, n);
      ci_prev_lb = ci.lower
      ci_prev_ub = ci.upper
// console.log("x=",x);
// console.log("n=",n);
// console.log("ci_prev_lb=",ci_prev_lb);
// console.log("ci_prev_ub=",ci_prev_ub);


      // Predictive values, brute force
      let ci_prev = [ci_prev_lb, ci_prev_ub];
      let ci_se = [ci_se_lb, ci_se_ub];
      let ci_sp = [ci_sp_lb, ci_sp_ub];

      let ppv_list = [];
      let npvm1_list = [];

      for (let i = 0; i < 2; i++)
      {
        let prev_aux = ci_prev[i];
        for (let j = 0; j < 2; j++)
        {
          let se_aux = ci_se[j];
          for (let k = 0; k < 2; k++)
          {
            let sp_aux = ci_sp[k];

            let ac = prev_aux * n;
            let bd = n - ac;
            let a_p = se_aux * ac;
            let c_p = ac - a_p;
            let d_p = sp_aux * bd;
            let b_p = bd - d_p;

            let ppv = a_p / (a_p + b_p);
            let npvm1 = 1 - d_p / (c_p + d_p);

            ppv_list.push(ppv);
            npvm1_list.push(npvm1);
          }
        }
      }
// console.log(npvm1_list.map(x => x.toFixed(3)));

      ci_ppv_lb = Math.min(...ppv_list);
      ci_ppv_ub = Math.max(...ppv_list);
      ci_npvm1_lb = Math.min(...npvm1_list);
      ci_npvm1_ub = Math.max(...npvm1_list);
// console.log("ci_ppv_lb=",ci_ppv_lb);
// console.log("ci_ppv_ub=",ci_ppv_ub);
// console.log("ci_npvm1_lb=",ci_npvm1_lb);
// console.log("ci_npvm1_ub=",ci_npvm1_ub);

      // Predictive values, analytic
      ppv = a_f/(a_f+b_f)
      s_ppv = Math.sqrt((ppv * (1 - ppv)) / (a_f+b_f));
      z_ppv = ppv / s_ppv;
      zH0_ppv = prev_input / s_ppv;
      z_diff_ppv = z_ppv - zH0_ppv;
      p_ppv = z_diff_ppv > 0 ? 1 - normalCDF(z_diff_ppv) : normalCDF(z_diff_ppv);

      npvm1 = 1-d_f/(c_f+d_f)
      s_npvm1 = Math.sqrt((npvm1 * (1 - npvm1)) / (c_f+d_f));
      z_npvm1 = npvm1 / s_npvm1;
      zH0_npvm1 = prev_input / s_npvm1;
      z_diff_npvm1 = z_npvm1 - zH0_npvm1;
      p_npvm1 = z_diff_npvm1 > 0 ? 1 - normalCDF(z_diff_npvm1) : normalCDF(z_diff_npvm1);
    }
// console.log("ppv=",ppv);
// console.log("s_ppv=",s_ppv);
// console.log("z_ppv=",z_ppv);
// console.log("zH0_ppv=",zH0_ppv);
// console.log("z_diff_ppv=",z_diff_ppv);
// console.log("p_ppv=",p_ppv);
// console.log("npvm1=",npvm1);
// console.log("s_npvm1=",s_npvm1);
// console.log("z_npvm1=",z_npvm1);
// console.log("zH0_npvm1=",zH0_npvm1);
// console.log("z_diff_npvm1=",z_diff_npvm1);
// console.log("p_npvm1=",p_npvm1);

    // Chen et al. 2015
    s_J2 = Math.sqrt(Math.pow(sp, 2) * Math.pow(s_se, 2) + Math.pow(se, 2) * Math.pow(s_sp, 2));
    z_J2 = J / s_J2;
    p_J2 = (z_J2 > 0)
      ? 1 - normalCDF(z_J2)
      : normalCDF(z_J2);
    let z_alpha = inverseNormal(1 - 0.05);
    ci_J2_lb = J - z_alpha * s_J2;
    ci_J2_ub = J + z_alpha * s_J2;

    plotChart(se, sp, prev_input, testName);

    // for presentation, convert in %
    se = se*100;
    ci_se_lb = ci_se_lb*100;
    ci_se_ub = ci_se_ub*100;
    sp = sp*100;
    ci_sp_lb = ci_sp_lb*100;
    ci_sp_ub = ci_sp_ub*100;
    ppv = ppv*100;
    ci_ppv_lb = ci_ppv_lb*100;
    ci_ppv_ub = ci_ppv_ub*100;
    npvm1 = npvm1*100;
    ci_npvm1_lb = ci_npvm1_lb*100;
    ci_npvm1_ub = ci_npvm1_ub*100;
    prev_input = prev_input*100;

    const matrixHTML = `
        <table class="matrix">
            <tr><td></td><td>D+</td><td>D-</td><td>Total</td></tr>
            <tr><td>T+</td><td>${a}</td><td>${b}</td><td>${a + b}</td></tr>
            <tr><td>T-</td><td>${c}</td><td>${d}</td><td>${c + d}</td></tr>
            <tr><td>Total</td><td>${a + c}</td><td>${b + d}</td><td>${n}</td></tr>
        </table>`;
    document.getElementById("matrixOutput").innerHTML = matrixHTML;

    const tbody = document.getElementById("resultsTable").querySelector("tbody");
    tbody.innerHTML = "";

    const suppressCI = (n < 2);
    document.getElementById("individualNote").style.display = suppressCI ? "block" : "none";
    
    const rows = [
        { variable: "Sens.", test: "se > 0.5", value: se, lower: ci_se_lb, upper: ci_se_ub, z: z_diff_se, p: p_se },
        { variable: "Spec.", test: "sp > 0.5", value: sp, lower: ci_sp_lb, upper: ci_sp_ub, z: z_diff_sp, p: p_sp },
        { variable: "Pre-test", test: "", value: prev_input },
        { variable: "PPV", test: "PPV &ne; Pre-test", value: ppv, lower: ci_ppv_lb, upper: ci_ppv_ub, z: z_diff_ppv, p: p_ppv },
        { variable: "1-NPV", test: "(1-NPV) &ne; Pre-test", value: npvm1, lower: ci_npvm1_lb, upper: ci_npvm1_ub, z: z_diff_npvm1, p: p_npvm1 },
        { variable: "Youden", test: "J > 0", value: J, lower: ci_J2_lb, upper: ci_J2_ub, z: z_J2, p: p_J2 },
    ];

// console.log(rows);
    rows.forEach(r => {
        const isYouden = r.variable === "Youden";
        const tr = document.createElement("tr");
        tr.innerHTML = `
            <td>${r.variable}</td>
            <td>${r.test || ""}</td>
            <td>${isYouden ? formatNumber(r.value) : formatPercent(r.value)}</td>
            <td>${suppressCI ? "" : (isYouden ? formatNumber(r.lower) : formatPercent(r.lower))}</td>
            <td>${suppressCI ? "" : (isYouden ? formatNumber(r.upper) : formatPercent(r.upper))}</td>
            <td>${suppressCI ? "" : (formatNumber(r.z))}</td>
            <td>${suppressCI ? "" : (formatPValue(r.p))}</td>`;
        tbody.appendChild(tr);
    });

}

function wilsonCI(p, n, alpha = 0.05) {
  let z = inverseNormal(1 - alpha / 2);
  let z2 = z * z;
  let denom = 1 + z2 / n;
  let center = p + z2 / (2 * n);
  let adj = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n);
  let lower = Math.max(0, (center - adj) / denom);
  let upper = Math.min(1, (center + adj) / denom);
  return { lower, upper };
}

function inverseNormal(p) {
  // Abramowitz & Stegun approximation, valid for p in (0,1)
  if (p <= 0 || p >= 1) throw new Error("p must be in (0,1)");
  if (p < 0.5) return -rationalApproximation(Math.sqrt(-2.0 * Math.log(p)));
  return rationalApproximation(Math.sqrt(-2.0 * Math.log(1 - p)));

  function rationalApproximation(t) {
    const c = [2.515517, 0.802853, 0.010328];
    const d = [1.432788, 0.189269, 0.001308];
    return t - ((c[0] + c[1] * t + c[2] * t * t) /
                (1 + d[0] * t + d[1] * t * t + d[2] * t * t * t));
  }
}

function normalCDF(z) {
    return 0.5 * (1 + erf(z / Math.sqrt(2)));
}

function erf(x) {
    const sign = (x >= 0) ? 1 : -1;
    x = Math.abs(x);
    const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
    const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
    const t = 1 / (1 + p * x);
    const y = 1 - (((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x));
    return sign * y;
}

function normalQuantile(p) {
    return Math.sqrt(2) * inverseErf(2 * p - 1);
}

function inverseErf(x) {
    const a = 0.147;
    const ln = Math.log(1 - x * x);
    const sgn = x < 0 ? -1 : 1;
    return sgn * Math.sqrt(Math.sqrt(Math.pow(2 / (Math.PI * a) + ln / 2, 2) - ln / a) - (2 / (Math.PI * a) + ln / 2));
}


function formatNumber(x) {
    return (typeof x === "number" && isFinite(x)) ? x.toFixed(4) : "";
}

function formatPValue(p) {
    return (typeof p === "number" && isFinite(p)) ?
        (p < 1e-4 ? p.toExponential(2) : p.toFixed(4)) : "";
}

function plotChart(se, sp, prev_input, testName) {

	const canvas = document.getElementById('diagnosticChart');
	const size = canvas.offsetWidth;
	canvas.width = size;
	canvas.height = size;

    if (!canvas || !se || !sp) return;

    const ctx = canvas.getContext('2d');

    try {
        if (window.diagnosticChart) {
            window.diagnosticChart.destroy();
        }
    } catch (e) {
        console.warn("No chart to destroy:", e);
    }

    const labels = [], ppvValues = [], npvm1Values = [];
    for (let p = 0; p <= 1.001; p += 0.05) {
        let ppv = (se * p) / ((se * p) + ((1 - sp) * (1 - p)));
        let npvm1 = 1 - (sp * (1 - p)) / (((1 - se) * p) + (sp * (1 - p)));
        labels.push(p);
        ppvValues.push(ppv);
        npvm1Values.push(npvm1);
    }

    const showMarkers = (prev_input > 0 && prev_input < 1);
    const ppv_at_prev = showMarkers ?
        (se * prev_input) / ((se * prev_input) + ((1 - sp) * (1 - prev_input))) : null;
    const npvm1_at_prev = showMarkers ?
        1 - (sp * (1 - prev_input)) / (((1 - se) * prev_input) + (sp * (1 - prev_input))) : null;

    const annotations = showMarkers ? {
        vLine: {
            type: 'line',
            xMin: prev_input,
            xMax: prev_input,
            borderColor: 'gray',
            borderWidth: 3,
            borderDash: [6, 6]
        },
        hLinePPV: {
            type: 'line',
            yMin: ppv_at_prev,
            yMax: ppv_at_prev,
            borderColor: 'blue',
            borderWidth: 3,
            borderDash: [6, 6]
        },
        hLineNPV: {
            type: 'line',
            yMin: npvm1_at_prev,
            yMax: npvm1_at_prev,
            borderColor: 'red',
            borderWidth: 3,
            borderDash: [6, 6]
        }
    } : {};

    window.diagnosticChart = new Chart(ctx, {
        type: 'line',
        data: {
            labels: labels,
            datasets: [
                {
                    label: "PPV",
                    data: ppvValues,
                    borderWidth: 5,
                    borderColor: "blue",
                    tension: 0.2,
                    fill: false,
                    pointRadius: 5,
                    pointBackgroundColor: "blue"
                },
                {
                    label: "1-NPV",
                    data: npvm1Values,
                    borderWidth: 5,
                    borderColor: "red",
                    tension: 0.2,
                    fill: false,
                    pointRadius: 5,
                    pointBackgroundColor: "red"
                }
            ]
        },
        options: {
            responsive: false,
            scales: {
                x: {
                    min: 0,
                    max: 1,
      	            type: 'linear',
                    title: {
                        display: true,
                        text: "Pre-test or Prevalence (probability)",
                        font: {
                            size: 30
                        }
                    },
                    ticks: {
                        font: {
                            size: 24
                        }
                    }
                },
                y: {
                    min: 0,
                    max: 1,
                    title: {
                        display: true,
                        text: "Post-test result (probability)",
                        font: {
                            size: 30
                        }
                    },
                    ticks: {
                        font: {
                            size: 24
                        }
                    }
                }
            },
            plugins: {
                title: {
                    display: true,
                    text: testName,
                    font: {
                        size: 36
                    },
                    padding: {
                        top: 10,
                        bottom: 20
                    }
                },
                legend: {
                    display: true,
                    labels: {
                        font: {
                            size: 24
                        }
                    }
                },
                annotation: {
                    annotations: annotations
                }
            }
        },
        plugins: [Chart.registry.getPlugin('annotation')]
    });
}
function showError(msg) {
  const div = document.getElementById("errorMessage");
  div.textContent = msg;
  div.style.display = "block";
}

function clearError() {
  const div = document.getElementById("errorMessage");
  div.textContent = "";
  div.style.display = "none";
}

function formatPercent(x) {
    return (typeof x === "number" && isFinite(x)) ? x.toFixed(4) + "%" : "";
}


    </script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@1.4.0"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jstat/1.9.5/jstat.min.js"></script>

<style>
  body {
    font-family: sans-serif;
    font-size: 30px;
    line-height: 1.4;
    margin: 10px;
  }

  input, select, button {
    font-size: 30px;
  }

  table {
    font-size: 25px;
    width: 100%;
    border-collapse: collapse;
  }

  th, td {
    padding: 6px;
    border: 1px solid #ccc;
    text-align: center;
  }

  h1, h2, h3 {
    font-size: 28px;
    margin-top: 1em;
  }
	#diagnosticChart {
	width: 60%;
	max-width: 100%;
	aspect-ratio: 1 / 1;
	display: block;
	margin: 20px auto;
	}
</style>
</head>
<body>
<p><small>Versão: 2025-07-11</small></p>
    <h1>Youden Index Calculator</h1>
    <label>Test name: <input type="text" id="testName" value=""></label><br><br>

    <h3>There are two available strategies for diagnostic evaluation. Please fill in only one of them and leave the other blank.</h3>

<hr style="height: 4px; background-color: #006400; border: none; margin: 20px 0;">
<span style="font-size: 50px; color: #006400; font-weight: bold;">- 1 -</span>
    <h3>To estimate the probability of disease for an individual patient given a positive (PPV) or negative result (1–NPV), enter sensitivity, specificity, and your diagnostic impression as the pre-test probability:</h3>
    <table class="matrix">
        <tr><td>Sensitivity (0 to 100%)</td><td><input type="number" min="0" max="100" step=0.001 id="se_input" value=""></td></tr>
        <tr><td>Specificity (0 to 100%)</td><td><input type="number" min="0" max="100" step=0.001 id="sp_input" value=""></td></tr>
    </table><br>

    <h3>Optionally, to reconstruct a 2×2 contingency table from a population-based study, provide the total sample size and the number of diseased individuals. (Note: sensitivity and specificity must be filled in above for this option to work):</h3>
    <table class="matrix">
        <tr><td>Total patients (n)</td><td><input type="number" id="n_input" min="1" max="999999" maxlength="6" value=""></td></tr>
        <tr><td>Diseased patients (D+)</td><td><input type="number" id="dplus_input" min="1" max="999999" maxlength="6"  value=""></td></tr>
    </table><br>

<hr style="height: 4px; background-color: #006400; border: none; margin: 20px 0;">
<span style="font-size: 50px; color: #006400; font-weight: bold;">- 2 -</span>
    <h3>Another way to reconstruct a population-based study is to enter a full 2x2 table using integer counts for each cell. Leave the fields above blank, as sensitivity and specificity will be calculated from the data:</h3>
    <table class="matrix">
        <tr><td></td><td>D+</td><td>D-</td></tr>
        <tr><td>T+</td><td><input type="number" id="a"  min="1" max="999999" maxlength="6" value=""></td><td><input type="number" id="b" min="1" max="999999" maxlength="6" value=""></td></tr>
        <tr><td>T-</td><td><input type="number" id="c" min="1" max="999999" maxlength="6" value=""></td><td><input type="number" id="d" min="1" max="999999" maxlength="6" value=""></td></tr>
    </table><br>

<hr style="height: 4px; background-color: #006400; border: none; margin: 20px 0;">
    <h3>The field below can be used to assess predictive values (PPV and 1–NPV) for an individual patient (pre-test probability) or for a population (prevalence) (note that predictive values are not calculated when only the 2x2 table is provided):</h3>
    <label>Pre-test or Prevalence (0 to 100%): <input type="number" min="0" max="100" step=0.001 id="prev_input" value=""></label><br>

<hr style="height: 4px; background-color: #006400; border: none; margin: 20px 0;">

<div align=center>
    <button onclick="runCalculation()">Calcular</button>
    <div align=center id="errorMessage" style="color: red; font-weight: bold; display: none;"></div>
</div>

<hr style="height: 4px; background-color: #006400; border: none; margin: 20px 0;">
    <h3>Summary</h3>
    <div id="matrixOutput"></div>

    <h3>Estimates</h3>
<div id="individualNote" style="display: none; color: #800000; font-size: 26px; margin-top: 10px;">
  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;(In the diagnostic evaluation of an individual patient, confidence intervals and statistical tests are not applicable)
</div>
    
    <table id="resultsTable">
        <thead>
            <tr>
                <th>Variable</th>
                <th>H1 test</th>
                <th>Estimate</th>
                <th>Lower</th>
                <th>Upper</th>
                <th>Z</th>
                <th>p-value</th>
            </tr>
        </thead>
        <tbody></tbody>
    </table>
    <h3>Graph: PPV and 1–NPV vs Prevalence</h3>
    <canvas id="diagnosticChart"></canvas>
</body>
</html>
